Содержание

  • 1  Открываем файлы с данными и изучаем общую информацию.
    • 1.1  Техническое задание
    • 1.2  Описание данных
  • 2  Предобработка данных, продолжаем изучать данные
  • 3  Исследовательский анализ данных
  • 4  Проверка статистической разницы долей z-критерием.
  • 5  Общий вывод

Оценка A/B-тестирования¶

Моя задача — провести оценку результатов A/B-теста. В распоряжении есть датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов.

  • Оценить корректность проведения теста. Проверить:
    • Соответствие данных требованиям технического задания. В случае нарушения требований оцените, насколько существенно негативное влияние на результаты теста. При проверке условий технического задания и фильтрации данных, контролируйте количество уникальных пользователей, принимающих участие в тесте – это поможет детальнее изучить влияние каждого условия на аудиторию теста.
    • Время проведения теста. Убедитесь, что оно не совпадает с маркетинговыми и другими активностями.
    • Аудиторию теста. Удостоверьтесь, что нет пересечений с конкурирующим тестом и нет пользователей, участвующих в двух группах теста одновременно. Проверьте равномерность распределения пользователей по группам и правильность их формирования.
  • Проанализировать результаты теста
In [1]:
import pandas as pd
import numpy as np
import seaborn as sns

import datetime as dt
import scipy.stats as st
import plotly.express as px
import math as mth
from plotly import graph_objects as go
import matplotlib.pyplot as plt

sns.set_style("darkgrid")
In [2]:
#функция расчета  z-критерия для двух групп
def z_test(successes_1, successes_2, trials_1, trials_2, alpha=0.05, bonferroni_alpha = 1): 
    alpha = alpha / bonferroni_alpha # критический уровень статистической значимости
    successes = np.array([successes_1,successes_2])
    trials = np.array([trials_1, trials_2])


    # пропорция успехов в первой группе:
    p1 = successes[0]/trials[0]

    # пропорция успехов во второй группе:
    p2 = successes[1]/trials[1]
    
    print(successes[0], successes[1],trials[0] , trials[1])

    # пропорция успехов в комбинированном датасете:
    p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])

    # разница пропорций в датасетах
    difference = p1 - p2 

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  

    p_value = (1 - distr.cdf(abs(z_value))) * 2

    print('p-значение: ', p_value)
    print('значение alpha:', alpha)

    if p_value < alpha:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
    else:
        print(
            'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
        ) 

Открываем файлы с данными и изучаем общую информацию.¶

In [3]:
ab_project_marketing_events = pd.read_csv('https://code.s3.yandex.net//datasets/ab_project_marketing_events.csv')
final_ab_new_users = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_new_users.csv')
final_ab_events = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_events.csv')
final_ab_participants = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_participants.csv')
In [4]:
ab_project_marketing_events.head(3)
Out[4]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
1 St. Valentine's Day Giveaway EU, CIS, APAC, N.America 2020-02-14 2020-02-16
2 St. Patric's Day Promo EU, N.America 2020-03-17 2020-03-19
In [5]:
final_ab_new_users.head(3)
Out[5]:
user_id first_date region device
0 D72A72121175D8BE 2020-12-07 EU PC
1 F1C668619DFE6E65 2020-12-07 N.America Android
2 2E1BF1D4C37EA01F 2020-12-07 EU PC
In [6]:
final_ab_events.head(3)
Out[6]:
user_id event_dt event_name details
0 E1BDDCE0DAFA2679 2020-12-07 20:22:03 purchase 99.99
1 7B6452F081F49504 2020-12-07 09:22:53 purchase 9.99
2 9CD9F34546DF254C 2020-12-07 12:59:29 purchase 4.99
In [7]:
final_ab_participants.head(3)
Out[7]:
user_id group ab_test
0 D1ABA3E2887B6A73 A recommender_system_test
1 A7A3664BD6242119 A recommender_system_test
2 DABC14FDDFADD29E A recommender_system_test
In [8]:
dataset = [ab_project_marketing_events, final_ab_new_users, final_ab_events, final_ab_participants]
In [9]:
sp = ['ab_project_marketing_events', 'final_ab_new_users', 'final_ab_events', 'final_ab_participants']
sp
Out[9]:
['ab_project_marketing_events',
 'final_ab_new_users',
 'final_ab_events',
 'final_ab_participants']
In [10]:
for i, t in zip(sp, dataset):
    print(i)
    print()
    print(t.info())
    print()
    print(t.isna().agg(['sum', 'mean']))
    print()
    print(f"Дубликатов: {t.duplicated().sum()}")
    print()
    print('-----------------------')
ab_project_marketing_events

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   name       14 non-null     object
 1   regions    14 non-null     object
 2   start_dt   14 non-null     object
 3   finish_dt  14 non-null     object
dtypes: object(4)
memory usage: 576.0+ bytes
None

      name  regions  start_dt  finish_dt
sum    0.0      0.0       0.0        0.0
mean   0.0      0.0       0.0        0.0

Дубликатов: 0

-----------------------
final_ab_new_users

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   user_id     61733 non-null  object
 1   first_date  61733 non-null  object
 2   region      61733 non-null  object
 3   device      61733 non-null  object
dtypes: object(4)
memory usage: 1.9+ MB
None

      user_id  first_date  region  device
sum       0.0         0.0     0.0     0.0
mean      0.0         0.0     0.0     0.0

Дубликатов: 0

-----------------------
final_ab_events

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   user_id     440317 non-null  object 
 1   event_dt    440317 non-null  object 
 2   event_name  440317 non-null  object 
 3   details     62740 non-null   float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB
None

      user_id  event_dt  event_name        details
sum       0.0       0.0         0.0  377577.000000
mean      0.0       0.0         0.0       0.857512

Дубликатов: 0

-----------------------
final_ab_participants

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  18268 non-null  object
 1   group    18268 non-null  object
 2   ab_test  18268 non-null  object
dtypes: object(3)
memory usage: 428.3+ KB
None

      user_id  group  ab_test
sum       0.0    0.0      0.0
mean      0.0    0.0      0.0

Дубликатов: 0

-----------------------

Пропуски есть только final_ab_events['details'], посмотрим на них

In [11]:
final_ab_events[final_ab_events['details'].isna()].iloc[:5]
Out[11]:
user_id event_dt event_name details
62740 2E1BF1D4C37EA01F 2020-12-07 09:05:47 product_cart NaN
62741 50734A22C0C63768 2020-12-07 13:24:03 product_cart NaN
62742 5EB159DA9DC94DBA 2020-12-07 22:54:02 product_cart NaN
62743 084A22B980BA8169 2020-12-07 15:25:55 product_cart NaN
62744 0FC21E6F8FAA8DEC 2020-12-07 06:56:27 product_cart NaN

Посмотрим какие вообще есть события в event_name

In [12]:
final_ab_events['event_name'].value_counts(ascending=False)
Out[12]:
login           189552
product_page    125563
purchase         62740
product_cart     62462
Name: event_name, dtype: int64

Проверим что при событии purchase нет пропусков

In [13]:
final_ab_events[(final_ab_events['details'].isna()) & (final_ab_events['event_name'] == 'purchase')]
Out[13]:
user_id event_dt event_name details

Из всех событий вычтем покупки и должны получить все пропуски

In [14]:
(len(final_ab_events) - len(final_ab_events[final_ab_events['event_name'] == 'purchase']) 
                                     == final_ab_events['details'].isna().sum())
Out[14]:
True
In [15]:
for i, t in zip(sp, dataset):
    print(i)
    print()
    for index in t.columns:
        row = t[index].nunique()
        print(f'Уникальных значений {index}: {row}')
    print('-----------------')
ab_project_marketing_events

Уникальных значений name: 14
Уникальных значений regions: 6
Уникальных значений start_dt: 14
Уникальных значений finish_dt: 14
-----------------
final_ab_new_users

Уникальных значений user_id: 61733
Уникальных значений first_date: 17
Уникальных значений region: 4
Уникальных значений device: 4
-----------------
final_ab_events

Уникальных значений user_id: 58703
Уникальных значений event_dt: 267268
Уникальных значений event_name: 4
Уникальных значений details: 4
-----------------
final_ab_participants

Уникальных значений user_id: 16666
Уникальных значений group: 2
Уникальных значений ab_test: 2
-----------------

Техническое задание¶

  • Название теста: recommender_system_test;
  • группы: А — контрольная, B — новая платёжная воронка;
  • дата запуска: 2020-12-07;
  • дата остановки набора новых пользователей: 2020-12-21;
  • дата остановки: 2021-01-04;
  • аудитория: 15% новых пользователей из региона EU;
  • назначение теста: тестирование изменений, связанных с внедрением улучшенной рекомендательной системы;
  • ожидаемое количество участников теста: 6000.
  • ожидаемый эффект: за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%:
    • конверсии в просмотр карточек товаров — событие product_page,
    • просмотры корзины — product_cart,
    • покупки — purchase.

Описание данных¶

ab_project_marketing_events — календарь маркетинговых событий на 2020 год.

Структура файла:

  • name — название маркетингового события;
  • regions — регионы, в которых будет проводиться рекламная кампания;
  • start_dt — дата начала кампании;
  • finish_dt — дата завершения кампании.

final_ab_new_users — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года.

Структура файла:

  • user_id — идентификатор пользователя;
  • first_date — дата регистрации;
  • region — регион пользователя;
  • device — устройство, с которого происходила регистрация.

final_ab_events — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.

Структура файла:

  • user_id — идентификатор пользователя;
  • event_dt — дата и время события;
  • event_name — тип события;
  • details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.

final_ab_participants — таблица участников тестов.

Структура файла:

  • user_id — идентификатор пользователя;
  • ab_test — название теста;
  • group — группа пользователя.
In [16]:
print(f" Минимальная дата старта рекламной компании: {ab_project_marketing_events['start_dt'].min()}")
print(f" Максимальная дата старта рекламной компании: {ab_project_marketing_events['start_dt'].max()}")
 Минимальная дата старта рекламной компании: 2020-01-25
 Максимальная дата старта рекламной компании: 2020-12-30
In [17]:
print(f" Минимальная дата завершения рекламной компании: {ab_project_marketing_events['finish_dt'].min()}")
print(f" Максимальная дата завершения рекламной компании: {ab_project_marketing_events['finish_dt'].max()}")
 Минимальная дата завершения рекламной компании: 2020-02-07
 Максимальная дата завершения рекламной компании: 2021-01-07
In [18]:
print(f" Минимальная дата регистрации: {final_ab_new_users['first_date'].min()}")
print(f" Максимальная дата регистрации: {final_ab_new_users['first_date'].max()}")
 Минимальная дата регистрации: 2020-12-07
 Максимальная дата регистрации: 2020-12-23

Зацепили два дня лишних, позже удалим их.

In [19]:
print(f" Минимальная дата события: {final_ab_events['event_dt'].min()}")
print(f" Максимальная дата события: {final_ab_events['event_dt'].max()}")
 Минимальная дата события: 2020-12-07 00:00:33
 Максимальная дата события: 2020-12-30 23:36:33

По ТЗ мы должны были собирать данные новых пользователей по 04/01, по факту события прекратились 30/12.
Посмотрим распределение событий по дате

In [20]:
# подготовим таблицу
t = final_ab_events
t['event_dt'] = pd.to_datetime(t['event_dt']).dt.date
# строим график
plt.figure(figsize=(15,5))
g = sns.histplot(x='event_dt', data=t,)
g.set_xlabel('Дата',fontsize=12)
g.set_ylabel('Количество событий ', fontsize=12)
g.set_title('Распределение собитий по датам', fontsize=15)
plt.show()

Странно что действия прекратились 30/12 возможно или раньше остановили тест или был сбой

Посмотрим из каких регионов есть пользователи

In [21]:
final_ab_new_users['region'].value_counts(normalize=True, ascending=False)
Out[21]:
EU           0.749518
N.America    0.148300
CIS          0.051107
APAC         0.051075
Name: region, dtype: float64

Вывод:
У нас есть 4 датафрейма, дубликатов нет, в одном есть пропуски, пропуски появились в столбце details для событий не связанных с оплатой. Сбор данных прекратили 30/12, по ТЗ тест должен был идти до 04/01.
Для дальнейшего исследования нам надо дату привести к формату дата и удалим пользователей зарегистрировавшихся после 21/12/2020

Предобработка данных, продолжаем изучать данные¶

Дату приведем в к формату дата

In [22]:
ab_project_marketing_events['start_dt'] =  pd.to_datetime(ab_project_marketing_events['start_dt'])
ab_project_marketing_events['finish_dt'] =  pd.to_datetime(ab_project_marketing_events['finish_dt'])
final_ab_new_users['first_date'] =  pd.to_datetime(final_ab_new_users['first_date'])
final_ab_events['event_dt'] =  pd.to_datetime(final_ab_events['event_dt'])
In [23]:
df = final_ab_participants[final_ab_participants['ab_test'] == 'recommender_system_test']
len(df)
Out[23]:
6701

Отфильтруем по группе и проверим что массивы с ID не пересекаются

In [24]:
np.intersect1d(df.query('group == "B"')['user_id'], 
               df.query('group == "A"')['user_id'])
Out[24]:
array([], dtype=object)

Пользователи не пересекаются.

In [25]:
final_ab_participants['ab_test'].value_counts(normalize=True)
Out[25]:
interface_eu_test          0.633184
recommender_system_test    0.366816
Name: ab_test, dtype: float64

Помимо нашего теста, пользователи принимали участие еще в другом тесте, давайте посомтрим пересекают ли они

In [26]:
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test"')['user_id'], 
               final_ab_participants.query('ab_test == "interface_eu_test"')['user_id']))
Out[26]:
1602

1602 пользователя принимали участи в обоих тестах.

Проверим сколько было в группах A

In [27]:
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "A"')['user_id'], 
               final_ab_participants.query('ab_test == "interface_eu_test" and group == "A"')['user_id']))
Out[27]:
482

Проверим сколько было в группах B

In [28]:
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "B"')['user_id'], 
               final_ab_participants.query('ab_test == "interface_eu_test" and group == "B"')['user_id']))
Out[28]:
344

В recommender_system_test в А и interface_eu_test B

In [29]:
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "A"')['user_id'], 
               final_ab_participants.query('ab_test == "interface_eu_test" and group == "B"')['user_id']))
Out[29]:
439

В recommender_system_test в B и interface_eu_test A

In [30]:
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "B"')['user_id'], 
               final_ab_participants.query('ab_test == "interface_eu_test" and group == "A"')['user_id']))
Out[30]:
337

Можем предположить, что пересечение пользователей никак не скажутся на нашем эксперименте.
Посмотрим проходили ли еше маркетинговые события в исследуемый нами период.

In [31]:
ab_project_marketing_events[(ab_project_marketing_events['start_dt'] >= '2020-12-07') 
                            & ((ab_project_marketing_events['start_dt'] <= '2021-01-04'))]
Out[31]:
name regions start_dt finish_dt
0 Christmas&New Year Promo EU, N.America 2020-12-25 2021-01-03
10 CIS New Year Gift Lottery CIS 2020-12-30 2021-01-07

В это время проходило два маркетинговых события, если одно началось 30/12 ( когда мы остановили сбор данных), то второе пришлось на наши даты, оно могло оказать влияния на поведение наших пользователей, но в равно доле.

Удалим пользователей которые зарегистрировались после 2020-12-21

In [32]:
t = len(final_ab_new_users)
In [33]:
final_ab_new_users = final_ab_new_users[final_ab_new_users['first_date'] <= '2020-12-21'].copy()
In [34]:
print( f" Удалилили: {t - len(final_ab_new_users)} пользователей")
 Удалилили: 5263 пользователей

Объеденим df и final_ab_new_users

In [35]:
df = df.merge(final_ab_new_users, on='user_id', how='left').copy()
df.head(3)
Out[35]:
user_id group ab_test first_date region device
0 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 EU PC
1 A7A3664BD6242119 A recommender_system_test 2020-12-20 EU iPhone
2 DABC14FDDFADD29E A recommender_system_test 2020-12-08 EU Mac
In [36]:
print(f"Пропусков : {df['first_date'].isna().sum()}")
Пропусков : 0

Посмотрим есть ли пользователи которые зарегистрировались после 2020-12-21

In [37]:
df['first_date'].max()
Out[37]:
Timestamp('2020-12-21 00:00:00')

После объединения таблиц, остались только те пользователи, которые зарегистрировались до 21/12/2020

Посмотрим на распределение по регионам, по ТЗ должно быть 15% новых пользователей из региона EU

In [38]:
df['region'].value_counts().to_frame()
Out[38]:
region
EU 6351
N.America 223
APAC 72
CIS 55
In [39]:
(df['region'].value_counts().to_frame()[:1] 
                 / final_ab_new_users['region'].value_counts().to_frame()[:1]) * 100
Out[39]:
region
EU 15.0

Получилось меньше, чем планировали в ТЗ

Как раз 15 процентов.

In [40]:
print(f"У нас пользователи зарегистрировавшиеся в течении:{(df['first_date'].max() - df['first_date'].min()).days} дней")
У нас пользователи зарегистрировавшиеся в течении:14 дней

Посомтрим на распределение пользователей по группам

In [41]:
group = df['group'].value_counts(normalize=True).to_frame()
group
Out[41]:
group
A 0.570661
B 0.429339

В группу А попало больше пользователей

Вывод:
В тесте приняло участие 6701 человек, часть из них принимало участие в другом тесте с интерфейсом, но были там распределены более менее равномерно (350-450 чел) по 4 корзинам, делать так плохо, но думаю этого количества достаточно, что б считать, что это не скажется на результате нашего эксперимента. Так же в это время проходило маркетинговое событие, которое могло оказать влияние на тест.
Ровно 15% новых пользователей из региона EU, как и просили в ТЗ.
Пользователи в группе распределены не равномерно, но в достаточно количестве.

Исследовательский анализ данных¶

Проверим как распределены количество событий на пользователя

In [42]:
df = df.merge(final_ab_events, on='user_id', how='left').copy()
df.head(3)
Out[42]:
user_id group ab_test first_date region device event_dt event_name details
0 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 EU PC 2020-12-07 purchase 99.99
1 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 EU PC 2020-12-25 purchase 4.99
2 D1ABA3E2887B6A73 A recommender_system_test 2020-12-07 EU PC 2020-12-07 product_cart NaN
In [43]:
len(df)
Out[43]:
27724

В ТЗ за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%.
Давайте отберем этих пользователей

In [44]:
df = df[(df['event_dt'] - df['first_date']).dt.days <= 14]
len(df)
Out[44]:
24070
In [45]:
t = df.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
t['quantity_per_user'] = t['event_name'] / t['user_id']
t
Out[45]:
group event_name user_id quantity_per_user
0 A 18947 2747 6.897343
1 B 5123 928 5.520474

Пользователи в группе А совершают больше действий

In [46]:
plt.figure(figsize=(15,5))
ax = sns.barplot(data = t, x='group', y='event_name',  palette='Paired')
ax.set_xlabel('',fontsize=12)
ax.set_ylabel('Количество событий', fontsize=12)
ax.set_title('Распределение количества событий', fontsize=15)
plt.xticks(rotation = 25)
plt.show()

После того, как мы установили лайфтайм, дисбаланс классов усилился.
Раньше было в процентах

In [47]:
group
Out[47]:
group
A 0.570661
B 0.429339

Стало

In [48]:
np.round(t.iloc[0:2, 2] / t['user_id'].sum() * 100, 2).to_frame()
Out[48]:
user_id
0 74.75
1 25.25

Посмортим на распредеение событий

In [49]:
plt.figure(figsize=(15,5))
g = sns.histplot(x='event_dt', data=df, hue='group')
g.set_xlabel('Дата собятия',fontsize=12)
g.set_ylabel('Количество событий ', fontsize=12)
g.set_title('Распределение событий', fontsize=15)
plt.xticks(rotation=45)

plt.show()

Группа А совершает больше событий, но там и больше выборка.
Посомтрим на отношение групп по дням

In [50]:
relation = df.pivot_table(index='event_dt', values='event_name', columns='group', aggfunc='count' ).reset_index()
relation['event_dt'] = relation['event_dt'].astype('str')
relation['percent'] = (relation['A'] / relation['B'])
relation.iloc[:5]
Out[50]:
group event_dt A B percent
0 2020-12-07 331 378 0.875661
1 2020-12-08 341 252 1.353175
2 2020-12-09 385 361 1.066482
3 2020-12-10 350 263 1.330798
4 2020-12-11 374 168 2.226190
In [51]:
f, ax = plt.subplots(figsize=(15, 5), dpi= 250)

sns.barplot(data=relation, x=relation['event_dt'], y= relation['percent'], color='steelblue')

plt.title( 'Отношение событий между группами', fontsize=15)
plt.xlabel('дата события', fontsize=12), 
plt.ylabel('Доля', fontsize=12) 
plt.xticks(rotation=90)
plt.show()

Если первые 4 дня конверсия была одинаковой, то позже группа А стала показывать больше событий.
У меня на графике нет 30 декабря, странно, давайте посмотрим события в этот день

In [52]:
df[df['event_dt'] == '2020-12-30']
Out[52]:
user_id group ab_test first_date region device event_dt event_name details

Событий нет, скорее всего 30/12 что то произошло и мы перестали фиксировать события.
Посмотрим на общее количество событий и количество событий по категориям

In [53]:
# подготовим таблицу
group_a = df[df['group'] == 'A'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(group_a, x='event_dt', y='group', 
             width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
    title='Общее количество событий и количество событий по категориям группа А',
    xaxis_title= '',
    yaxis_title='Количество событий')

fig.update_traces( textfont_size = 12 ,  textangle = 0,  textposition = "outside" ,  cliponaxis = False ) 
fig.show()
In [54]:
# подготовим таблицу
group_b = df[df['group'] == 'B'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(group_b, x='event_dt', y='group', 
             width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
    title='Общее количество событий и количество событий по категориям группа B',
    xaxis_title= '',
    yaxis_title='Количество событий')

fig.update_traces( textfont_size = 12 ,  textangle = 0,  textposition = "outside" ,  cliponaxis = False ) 
fig.show()

Это было видно и ранее, но я только сейчас обратил внимание, что в группе В активность выше в первые дни компании, поэтому и на графике отношения событий показатели и были в районе 1 а в группе А на вторую неделю начался рост.
С 21/12 в обоих группах пошел спад.
Посмотрим сколько пользователей приходили каждый 5 дней в наши группы

In [55]:
test_1 = df[df['first_date'] <= '2020-12-11']
test_1.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
Out[55]:
group event_name user_id
0 A 3004 501
1 B 2174 365
In [56]:
test = df[(df['first_date'] >= '2020-12-12') & (df['first_date'] < '2020-12-17')]
test.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
Out[56]:
group event_name user_id
0 A 6508 884
1 B 1547 271
In [57]:
test = df[(df['first_date'] >= '2020-12-17') & (df['first_date'] <= '2020-12-21')]
test.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
Out[57]:
group event_name user_id
0 A 9435 1362
1 B 1402 292

Из таблиц видно, что с течением времени сплитование стало больше отправлять в группу А, из-за этого и произошли видимые изменения на графике.

In [58]:
# подготовим таблицу
test_A = test_1[test_1['group'] == 'A'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(test_A, x='event_dt', y='group', 
             width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
    title='Общее количество событий и количество событий по категориям группа А',
    xaxis_title= '',
    yaxis_title='Количество событий')

fig.update_traces( textfont_size = 12 ,  textangle = 0,  textposition = "outside" ,  cliponaxis = False ) 
fig.show()
In [59]:
# подготовим таблицу
test_B = test_1[test_1['group'] == 'B'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(test_B, x='event_dt', y='group', 
             width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
    title='Общее количество событий и количество событий по категориям группа B',
    xaxis_title= '',
    yaxis_title='Количество событий')

fig.update_traces( textfont_size = 12 ,  textangle = 0,  textposition = "outside" ,  cliponaxis = False ) 
fig.show()

Мы отобрали пользователей, которые пришли к нам до 11/12, можно заметить, что поведение у них схоже.
Так же можем увидеть, что как правило пользователь приходит на сайт и в первые дни совершает все этапы воронки.
Посмотрим как регистрировались пользователи в эти 5 дней

In [60]:
sts = df[(df['first_date'] <= '2020-12-11') & (df['event_name'] == 'login')]['first_date']
sts = pd.to_datetime(sts).dt.day
sts.hist(bins=10, color='steelblue',figsize=(15, 5), ec="darkgrey")

plt.title('Распределение регситраций в первые 5 дней', fontsize=15) 
plt.xlabel('', fontsize=12) 
plt.ylabel('количество регистраций', fontsize=10) 
plt.xticks(rotation = 90)
plt.show() 
In [61]:
funnel_a = df[df['group'] == "A"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_a.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_a = funnel_a.sort_index()
funnel_a
Out[61]:
event_name count
0 login 2747
1 product_page 1780
2 product_cart 824
3 purchase 872
In [62]:
funnel_b = df[df['group'] == "B"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_b.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_b = funnel_b.sort_index()
funnel_b
Out[62]:
event_name count
0 login 927
1 product_page 523
2 product_cart 255
3 purchase 256
In [63]:
fig = go.Figure()

fig.add_trace(go.Funnel(name = 'A',
                        y = funnel_a['event_name'],
                        x = funnel_a['count'],
                        opacity = 0.9,
                        textposition = 'inside',
                        textinfo = 'value + percent previous + percent initial'))

fig.add_trace(go.Funnel(name = 'B',
                        y = funnel_b['event_name'],
                        x = funnel_b['count'],
                        opacity = 0.9,
                        textposition = 'auto',
                        textinfo = 'value + percent previous + percent initial'))

                        
fig.update_layout(title_text='Воронка событий по группам')
fig.show()

Поведение пользователей в группе А выглядит лучше, больший процент пользователей совершает покупку.
Часть пользователей делает покупку пропуская страницу корзины

Ранее в ходе исследования, мы отметили адекватное распределение по группам в первые 5 дней теста, давайте построим воронку для этих пользователей

In [64]:
cut = df[df['first_date'] <= '2020-12-11']
In [65]:
funnel_at = cut[cut['group'] == "A"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_at.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_at = funnel_at.sort_index()


funnel_bt = cut[cut['group'] == "B"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_bt.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_bt = funnel_bt.sort_index()
In [66]:
fig = go.Figure()

fig.add_trace(go.Funnel(name = 'A',
                        y = funnel_at['event_name'],
                        x = funnel_at['count'],
                        opacity = 0.9,
                        textposition = 'inside',
                        textinfo = 'value + percent previous + percent initial'))

fig.add_trace(go.Funnel(name = 'B',
                        y = funnel_bt['event_name'],
                        x = funnel_bt['count'],
                        opacity = 0.9,
                        textposition = 'auto',
                        textinfo = 'value + percent previous + percent initial'))

                        
fig.update_layout(title_text='Воронка событий по группам в первые 5 дней теста')
fig.show()

Если на общей воронке группа А выглядела лучше, то тут показатели схожи, группы ведут себя одинаково.
Говорить, что группа В покажет улучшение каждой метрики не менее, чем на 10% не представляется возможным.
Возможно если б сплиование работала корректно, общая воронка выглядела бы по другому

In [67]:
df[df['group'] == 'A']['event_name'].count()
Out[67]:
18947

Вывод:
По ТЗ мы должны посмотреть поведение новых пользователей за 14 дней с момента регистрации, по факту в выборку попали пользователи которые прожили в приложении больше дней.
Убрав их мы увидели большой дисбаланс групп, в группе А в три раза больше пользователей.
Мы предположили, что сбой мог произойти на 5 -6 день сплитования, так как до этого распределение смотрелось более менее адекватно. Так же тест проводился до 04/01 , но по факту сбор данных прекратился 29/12, за 30 декабря уже нет ни каких данных. Если смотреть общую воронку по группам, тио группа А выглядит предпочтительно, при этом если посмотреть воронку за первые 5 дней, группы смотрятся одинаково.
Так же на общую группу могло оказать влияние маркетинговое событие которое старануло 25/12.
В то время как не выборку из первых 5 дней влияние оно оказать не должно было…
Но 5 дней противоречит нашему ТЗ, мы не можем на него равняться, но можем утверждать, что результаты общего теста не объективны.

Проверка статистической разницы долей z-критерием.¶

In [68]:
df.groupby('group').agg(count=('user_id','nunique')).reset_index()
Out[68]:
group count
0 A 2747
1 B 928

В исследовании у меня было 927 в группе В, тут стало +1, пропусков и дубликатов нет. Найдем его

In [69]:
q = df[(df['group'] == "B") & (df['event_name'] == 'login')]['user_id'].unique()
w = df[(df['group'] == "B")]['user_id'].unique()
np.setdiff1d(w, q)
Out[69]:
array(['5FF8B6AB257B404F'], dtype=object)
In [70]:
df[df['user_id'] == '5FF8B6AB257B404F']
Out[70]:
user_id group ab_test first_date region device event_dt event_name details
8523 5FF8B6AB257B404F B recommender_system_test 2020-12-07 EU Android 2020-12-07 purchase 4.99

Посмотрим на все действия от первой таблицы

In [71]:
final_ab_events[final_ab_events['user_id'] == '5FF8B6AB257B404F']
Out[71]:
user_id event_dt event_name details
1411 5FF8B6AB257B404F 2020-12-07 purchase 4.99
53685 5FF8B6AB257B404F 2020-12-25 purchase 4.99
412914 5FF8B6AB257B404F 2020-12-25 login NaN

Странный клиент, хорошо, что единственный, не буду его учитывать

Сформируем таблицы

In [72]:
a_b_events_by_users = df.pivot_table(index='event_name', columns='group', values='user_id', aggfunc='nunique').reset_index()
a_b_events_by_users.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
a_b_events_by_users = a_b_events_by_users.sort_index()
a_b_events_by_users = a_b_events_by_users = a_b_events_by_users.iloc[1:4]
In [73]:
a_b_events_by_users
Out[73]:
group event_name A B
1 product_page 1780 523
2 product_cart 824 255
3 purchase 872 256
In [74]:
a_b_group = df[df['event_name'] == 'login'].groupby('group')['user_id'].nunique()
In [75]:
a_b_group
Out[75]:
group
A    2747
B     927
Name: user_id, dtype: int64

Проверим статистическую разницу долей z-критерием.. Сформулируем гипотезы

Сформулируем гипотезы.
Нулевая: Нет статистически значимого различия, нет оснований считать доли разными.
Альтернативная: Между долями есть значимая разница, отвергаем нулевую гипотезу.
Критический уровень статистической значимости укажем 0,05
Тк у нас сразу три теста, применим поправку Бонферрони для минимизации рисков

In [76]:
for i in a_b_events_by_users['event_name'].unique():
    print(f'Статистически значимые различая между группами A и B для события {i}')
    z_test(a_b_events_by_users.loc[a_b_events_by_users['event_name'] == i, 'A'], 
           a_b_events_by_users.loc[a_b_events_by_users['event_name'] == i, 'B'], 
           trials_1 = a_b_group.loc['A'], 
           trials_2 = a_b_group.loc['B'],
           bonferroni_alpha = 3)
    print()
    print('---')
    print()
Статистически значимые различая между группами A и B для события product_page
[1780] [523] 2747 927
p-значение:  [5.08436808e-06]
значение alpha: 0.016666666666666666
Отвергаем нулевую гипотезу: между долями есть значимая разница

---

Статистически значимые различая между группами A и B для события product_cart
[824] [255] 2747 927
p-значение:  [0.15034216]
значение alpha: 0.016666666666666666
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

---

Статистически значимые различая между группами A и B для события purchase
[872] [256] 2747 927
p-значение:  [0.01847463]
значение alpha: 0.016666666666666666
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными

---

Мы провели три теста, только в product_page отвергли нулевую гипотезу, это было видно и по воронке

Общий вывод¶

Нельзя брать во внимание результаты данного А/В теста, тк был допущен ряд нарушений, из основного:

  • сбор данных прекратился 30/12.
  • неверное сплитовани пользователей.
  • не удачно время запуска тест, в этом время проходили маркетинговые компании + возможен предновогодний ажиотаж.

Так же рекомендую если есть возможность не допускать участи пользователей в нескольких тестах.